BridgeJS: Emit static methods and properties on namespaced class entries#717
Merged
krodak merged 5 commits intoswiftwasm:mainfrom Apr 9, 2026
Merged
Conversation
The TypeScript `.d.ts` namespace-entry builder for `@JS(namespace:)` classes only emitted the constructor, silently dropping any `@JS static func` or `@JS static var` declared on the class. The equivalent path for non-namespaced classes (`renderExportedClass` → `dtsExportEntryPrinter`) iterates `klass.methods.filter(\.effects.isStatic)` and the static subset of `klass.properties`, so the output mismatched between the two paths. The generated JS class still carries the static members via `declarationPrefixKeyword: "static"` in `renderExportedClass`, and the namespace tree references it by symbol, so the JavaScript runtime works. TypeScript consumers, however, see an incomplete type and cannot call the static factory through `typeof MyNamespace.MyClass` without a hand-written augmentation. Mirror the non-namespaced path inside the `renderClassEntry` closure in `generateTypeScript` so namespaced classes emit their static methods and static properties alongside `new(...)`. Extended `Namespaces.swift` to exercise the codepath by adding a static factory and a static readonly property on the existing `__Swift.Foundation.Greeter` class. The Namespaces snapshot set captures the fixed output.
…endering
The `renderClassEntry` closure passed to
`buildHierarchicalExportsType` manually rebuilt the same
`ClassName: { new(...); staticMethod(); ... }` block that
`renderExportedClass` already produces as its `dtsExportEntry` return
value. The two paths had to be kept in sync by hand.
Replace the inline closure with a direct call to `renderExportedClass`
that discards the JS and DTS-type outputs and returns only the
`dtsExportEntry` slice. This makes the class namespace entry for
namespaced and non-namespaced classes identical by construction.
Thread `throws` through `buildHierarchicalExportsType`,
`populateTypeScriptExportLines`, and `generateTypeScript` to
accommodate the fact that `renderExportedClass` is throwing. Hoist the
`buildHierarchicalExportsType` call out of the `printer.indent` closure
so the `try` expression is in a throwing context.
…lass Extends the existing `__Swift.Foundation.UUID` class (which already has an `@JS init` and an `@JS func`) with a static factory `fromValue(_:)` and a static readonly property `placeholder`, then asserts both are reachable via `exports.__Swift.Foundation.UUID.fromValue` and `exports.__Swift.Foundation.UUID.placeholder` in `prelude.mjs`. This is the e2e counterpart to the snapshot regression added in the previous commit — it proves the generated JavaScript actually routes calls to the correct static thunks at runtime, not just that the `.d.ts` types are well-formed.
`renderExportedClass` was being called twice for every namespaced class: once in `collectLinkData` (to produce the JS body and instance interface) and once more inside the `renderClassEntry` closure (to produce the export-entry for `buildHierarchicalExportsType`), with the JS and instance-interface outputs discarded on the second call. Store the `dtsExportEntry` slice in `LinkData.namespacedClassDtsExportEntries` during the single `collectLinkData` pass, keyed by class name. The `renderClassEntry` closure is now a non-throwing dictionary lookup, so `buildHierarchicalExportsType`, `populateTypeScriptExportLines`, and `generateTypeScript` revert to non-throwing.
Static properties on `@JS(namespace:)` classes used `property.callName()` to build the Swift call expression. That method consults `property.staticContext`, which is stored as `.className(abiName)` (e.g. `__Swift_Foundation_UUID`), and emits `\(abiName).\(propertyName)`. The ABI-mangled name is not a valid Swift identifier so the generated thunk failed to compile. Static methods were unaffected because `renderSingleExportedMethod` passes `klass.swiftCallName` directly. Fix `PropertyRenderingContext.callName(for:)` for the `.classStatic` case to bypass `property.callName()` and build the expression directly from `klass.swiftCallName`. This preserves the ABI symbol name (which still comes through `context.className` → `klass.abiName` → `property.getterAbiName(className:)`) while producing a valid Swift call expression in the thunk body. Also remove the now-unnecessary comment from `prelude.mjs` that described what the test was checking (the assertion itself is self-explanatory).
kateinoigakukun
approved these changes
Apr 9, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Overview
@JS(namespace: …)classes silently drop their@JS static funcand@JS static vardeclarations from the generatedbridge-js.d.ts. The JS runtime is unaffected (the class definition still has the static members and the namespace tree references it by symbol), but TypeScript consumers see an incomplete type and cannot call static factories throughtypeof MyNamespace.MyClasswithout a hand-written augmentation.Root cause
BridgeJSLink.generateTypeScriptwrites theexport type Exportsblock using two different paths for classes:renderExportedClassinBridgeJSLink.swift): buildsdtsExportEntryby iteratingklass.methods.filter(\.effects.isStatic)and the static subset ofklass.properties. The produced entry is appended todata.dtsExportLinesand includes constructor + static methods + static properties.renderClassEntryclosure insidegenerateTypeScript, passed toNamespaceBuilder.buildHierarchicalExportsType): only emitted the constructor. Static methods and static properties were completely ignored.Because
renderExportedClassis still called for every class (to produce the JS class body), the JS side hasstatic makeDefault()etc. as expected, and thecreateExportsnamespace tree references the class symbol by name. That's why runtime calls toexports.__Swift.Foundation.Greeter.makeDefault()work even with a missing static declaration in.d.ts.Fix
Mirror the non-namespaced path inside the
renderClassEntryclosure so namespaced classes emit their statics alongsidenew(...):13 lines added to
BridgeJSLink.swift, no other source changes.Regression coverage
Snapshot tests (
Plugins/BridgeJS/Tests/):Namespaces.swiftalready had two namespaced classes but none exercised static methods or properties. Extended__Swift.Foundation.Greeterwith a static factory and a static readonly property. TheNamespaces.swiftandNamespaces.Global.swiftsnapshot sets now cover the fixed output across both the codegen and the linker test suites.E2E test (
Tests/BridgeJSRuntimeTests/,Tests/prelude.mjs):Extended the existing
@JS(namespace: "__Swift.Foundation") class UUIDwith@JS static func fromValue(_:) -> UUIDand@JS static var placeholder: String, then added assertions inprelude.mjsthat call both viaexports.__Swift.Foundation.UUID.fromValue(...)andexports.__Swift.Foundation.UUID.placeholder. This proves the generated JavaScript routes calls to the correct static thunks at runtime, not just that the.d.tstypes are well-formed.Follow-up: path consolidation
With the fix in place, the inline
renderClassEntryclosure became a duplicate of whatrenderExportedClassalready returns asdtsExportEntry. A follow-up commit replaces the closure with a directrenderExportedClasscall, discarding the JS and instance-interface outputs and keeping onlydtsExportEntry. This makes both paths share a single source of truth and threadsthrowsthroughbuildHierarchicalExportsType,populateTypeScriptExportLines, andgenerateTypeScript.Verification
swift test --package-path ./Plugins/BridgeJS --disable-experimental-prebuilts— 105/105 pass on the default swift-syntax (600.0.1).BRIDGEJS_OVERRIDE_SWIFT_SYNTAX_VERSION=602.0.0 swift test --package-path ./Plugins/BridgeJS --disable-experimental-prebuilts— 106/106 pass../Utilities/format.swift— no additional diffs../Utilities/bridge-js-generate.sh— AoT bindings updated for the new UUID static method and property added to the e2e test.